Merge "Revert .htaccess permissions back to 0644"
authorjenkins-bot <jenkins-bot@gerrit.wikimedia.org>
Tue, 29 Oct 2013 07:20:09 +0000 (07:20 +0000)
committerGerrit Code Review <gerrit@wikimedia.org>
Tue, 29 Oct 2013 07:20:09 +0000 (07:20 +0000)
19 files changed:
RELEASE-NOTES-1.23
includes/AutoLoader.php
includes/DefaultSettings.php
includes/Revision.php
includes/db/Database.php
includes/installer/Installer.php
includes/installer/WebInstallerOutput.php
includes/parser/LinkHolderArray.php
includes/resourceloader/ResourceLoader.php
includes/resourceloader/ResourceLoaderStartUpModule.php
maintenance/jsduck/categories.json
maintenance/populateRevisionLength.php
resources/Resources.php
resources/mediawiki/mediawiki.js
tests/phpunit/data/autoloader/TestAutoloadedCamlClass.php [new file with mode: 0644]
tests/phpunit/data/autoloader/TestAutoloadedClass.php [new file with mode: 0644]
tests/phpunit/data/autoloader/TestAutoloadedLocalClass.php [new file with mode: 0644]
tests/phpunit/data/autoloader/TestAutoloadedSerializedClass.php [new file with mode: 0644]
tests/phpunit/structure/AutoLoaderTest.php

index ec7b898..6c78253 100644 (file)
@@ -11,6 +11,13 @@ production.
 === Configuration changes in 1.23 ===
 
 === New features in 1.23 ===
+* ResourceLoader can utilize the Web Storage API to cache modules client-side.
+  Compared to the browser cache, caching in Web Storage allows ResourceLoader
+  to be more granular about evicting stale modules from the cache while
+  retaining the ability to retrieve multiple modules in a single HTTP request.
+  This capability can be enabled by setting $wgResourceLoaderStorageEnabled to
+  true. This feature is currently considered experimental and should only be
+  enabled with care.
 
 === Bug fixes in 1.23 ===
 * (bug 41759) The "updated since last visit" markers (on history pages, recent
index 0706fe3..dbba500 100644 (file)
@@ -1136,6 +1136,8 @@ $wgAutoloadLocalClasses = array(
 );
 
 class AutoLoader {
+       static $autoloadLocalClassesLower = null;
+
        /**
         * autoload - take a class name and attempt to load it
         *
@@ -1145,7 +1147,8 @@ class AutoLoader {
         * as well.
         */
        static function autoload( $className ) {
-               global $wgAutoloadClasses, $wgAutoloadLocalClasses;
+               global $wgAutoloadClasses, $wgAutoloadLocalClasses,
+                       $wgAutoloadAttemptLowercase;
 
                // Workaround for PHP bug <https://bugs.php.net/bug.php?id=49143> (5.3.2. is broken, it's
                // fixed in 5.3.6). Strip leading backslashes from class names. When namespaces are used,
@@ -1160,26 +1163,37 @@ class AutoLoader {
                        $filename = $wgAutoloadLocalClasses[$className];
                } elseif ( isset( $wgAutoloadClasses[$className] ) ) {
                        $filename = $wgAutoloadClasses[$className];
-               } else {
-                       # Try a different capitalisation
-                       # The case can sometimes be wrong when unserializing PHP 4 objects
+               } elseif ( $wgAutoloadAttemptLowercase ) {
+                       /*
+                        * Try a different capitalisation.
+                        *
+                        * PHP 4 objects are always serialized with the classname coerced to lowercase,
+                        * and we are plagued with several legacy uses created by MediaWiki < 1.5, see
+                        * https://wikitech.wikimedia.org/wiki/Text_storage_data
+                        */
                        $filename = false;
                        $lowerClass = strtolower( $className );
 
-                       foreach ( $wgAutoloadLocalClasses as $class2 => $file2 ) {
-                               if ( strtolower( $class2 ) == $lowerClass ) {
-                                       $filename = $file2;
-                               }
+                       if ( self::$autoloadLocalClassesLower === null ) {
+                               self::$autoloadLocalClassesLower = array_change_key_case( $wgAutoloadLocalClasses, CASE_LOWER );
                        }
 
-                       if ( !$filename ) {
+                       if ( isset( self::$autoloadLocalClassesLower[$lowerClass] ) ) {
                                if ( function_exists( 'wfDebug' ) ) {
-                                       wfDebug( "Class {$className} not found; skipped loading\n" );
+                                       wfDebug( "Class {$className} was loaded using incorrect case.\n" );
                                }
+                               $filename = self::$autoloadLocalClassesLower[$lowerClass];
+                       }
+               }
 
-                               # Give up
-                               return false;
+               if ( !$filename ) {
+                       if ( function_exists( 'wfDebug' ) ) {
+                               # FIXME: This is not very polite.  Assume we do not manage the class.
+                               wfDebug( "Class {$className} not found; skipped loading\n" );
                        }
+
+                       # Give up
+                       return false;
                }
 
                # Make an absolute path, this improves performance by avoiding some stat calls
index dbdd89e..d3c7b5f 100644 (file)
@@ -3322,6 +3322,22 @@ $wgResourceLoaderLESSImportPaths = array(
        "$IP/resources/mediawiki.less/",
 );
 
+/**
+ * Whether ResourceLoader should attempt to persist modules in localStorage on
+ * browsers that support the Web Storage API.
+ *
+ * @since 1.23 - Client-side module persistence is experimental. Exercise care.
+ */
+$wgResourceLoaderStorageEnabled = false;
+
+/**
+ * Cache version for client-side ResourceLoader module storage. You can trigger
+ * invalidation of the contents of the module store by incrementing this value.
+ *
+ * @since 1.23
+ */
+$wgResourceLoaderStorageVersion = 1;
+
 /** @} */ # End of resource loader settings }
 
 /*************************************************************************//**
@@ -5953,6 +5969,13 @@ $wgSpecialPages = array();
  */
 $wgAutoloadClasses = array();
 
+/**
+ * Switch controlling legacy case-insensitive classloading.
+ * Do not disable if your wiki must support data created by PHP4, or by
+ * MediaWiki 1.4 or earlier.
+ */
+$wgAutoloadAttemptLowercase = true;
+
 /**
  * An array of extension types and inside that their names, versions, authors,
  * urls, descriptions and pointers to localized description msgs. Note that
index 233eac0..4446173 100644 (file)
@@ -433,6 +433,36 @@ class Revision implements IDBAccessObject {
                return $fields;
        }
 
+       /**
+        * Return the list of revision fields that should be selected to create
+        * a new revision from an archive row.
+        * @return array
+        */
+       public static function selectArchiveFields() {
+               global $wgContentHandlerUseDB;
+               $fields = array(
+                       'ar_id',
+                       'ar_page_id',
+                       'ar_rev_id',
+                       'ar_text_id',
+                       'ar_timestamp',
+                       'ar_comment',
+                       'ar_user_text',
+                       'ar_user',
+                       'ar_minor_edit',
+                       'ar_deleted',
+                       'ar_len',
+                       'ar_parent_id',
+                       'ar_sha1',
+               );
+
+               if ( $wgContentHandlerUseDB ) {
+                       $fields[] = 'ar_content_format';
+                       $fields[] = 'ar_content_model';
+               }
+               return $fields;
+       }
+
        /**
         * Return the list of text fields that should be selected to read the
         * revision text
index 42f8482..9ffe665 100644 (file)
@@ -3910,7 +3910,7 @@ abstract class DatabaseBase implements IDatabase, DatabaseType {
 
                        }
                        $callers = implode( ', ', $callers );
-                       trigger_error( "DB transaction  callbacks still pending (from $callers)." );
+                       trigger_error( "DB transaction callbacks still pending (from $callers)." );
                }
        }
 }
index 276c572..2f45005 100644 (file)
@@ -1737,6 +1737,11 @@ abstract class Installer {
 
                // Some of the environment checks make shell requests, remove limits
                $GLOBALS['wgMaxShellMemory'] = 0;
+
+               // Don't bother embedding images into generated CSS, which is not cached
+               $GLOBALS['wgResourceLoaderLESSFunctions']['embeddable'] = function( $frame, $less ) {
+                       return $less->toBool( false );
+               };
        }
 
        /**
index 1df8f05..f2dc37f 100644 (file)
@@ -104,47 +104,83 @@ class WebInstallerOutput {
 
        /**
         * Get the raw vector CSS, flipping if needed
+        *
+        * @todo Possibly get rid of this function and use ResourceLoader in the manner it was
+        *   designed to be used in, rather than just grabbing a list of filenames from it,
+        *   and not properly handling such details as media types in module definitions.
+        *
         * @param string $dir 'ltr' or 'rtl'
         * @return String
         */
        public function getCSS( $dir ) {
-               $skinDir = dirname( dirname( __DIR__ ) ) . '/skins';
-
-               // All these files will be concatenated in sequence and loaded
-               // as one file.
-               // The string 'images/' in the files' contents will be replaced
-               // by '../skins/$skinName/images/', where $skinName is what appears
-               // before the last '/' in each of the strings.
-               $cssFileNames = array(
-
-                       // Basically the "skins.vector" ResourceLoader module styles
-                       'common/shared.css',
-                       'common/commonElements.css',
-                       'common/commonContent.css',
-                       'common/commonInterface.css',
-                       'vector/screen.css',
-
-                       // mw-config specific
-                       'common/config.css',
+               // All CSS files these modules reference will be concatenated in sequence
+               // and loaded as one file.
+               $moduleNames = array(
+                       'mediawiki.legacy.shared',
+                       'skins.vector',
+                       'mediawiki.legacy.config',
                );
 
+               $prepend = '';
                $css = '';
 
-               wfSuppressWarnings();
-               foreach ( $cssFileNames as $cssFileName ) {
-                       $fullCssFileName = "$skinDir/$cssFileName";
-                       $cssFileContents = file_get_contents( $fullCssFileName );
-                       if ( $cssFileContents ) {
-                               preg_match( "/^(\w+)\//", $cssFileName, $match );
-                               $skinName = $match[1];
-                               $css .= str_replace( 'images/', "../skins/$skinName/images/", $cssFileContents );
-                       } else {
-                               $css .= "/** Your webserver cannot read $fullCssFileName. Please check file permissions. */";
+               $cssFileNames = array();
+               $resourceLoader = new ResourceLoader();
+               foreach ( $moduleNames as $moduleName ) {
+                       $module = $resourceLoader->getModule( $moduleName );
+                       $cssFileNames = $module->getAllStyleFiles();
+
+                       wfSuppressWarnings();
+                       foreach ( $cssFileNames as $cssFileName ) {
+                               if ( !file_exists( $cssFileName ) ) {
+                                       $prepend .= ResourceLoader::makeComment( "Unable to find $cssFileName." );
+                                       continue;
+                               }
+
+                               if ( !is_readable( $cssFileName ) ) {
+                                       $prepend .= ResourceLoader::makeComment( "Unable to read $cssFileName. Please check file permissions." );
+                                       continue;
+                               }
+
+                               try {
+
+                                       if ( preg_match( '/\.less$/', $cssFileName ) ) {
+                                               // Run the LESS compiler for *.less files (bug 55589)
+                                               $compiler = ResourceLoader::getLessCompiler();
+                                               $cssFileContents = $compiler->compileFile( $cssFileName );
+                                       } else {
+                                               // Regular CSS file
+                                               $cssFileContents = file_get_contents( $cssFileName );
+                                       }
+
+                                       if ( $cssFileContents ) {
+                                               // Rewrite URLs, though don't bother embedding images. While static image
+                                               // files may be cached, CSS returned by this function is definitely not.
+                                               $cssDirName = dirname( $cssFileName );
+                                               $css .= CSSMin::remap(
+                                                       /* source */ $cssFileContents,
+                                                       /* local */ $cssDirName,
+                                                       /* remote */ '..' . str_replace(
+                                                               array( $GLOBALS['IP'], DIRECTORY_SEPARATOR ),
+                                                               array( '', '/' ),
+                                                               $cssDirName
+                                                       ),
+                                                       /* embedData */ false
+                                               );
+                                       } else {
+                                               $prepend .= ResourceLoader::makeComment( "Unable to read $cssFileName." );
+                                       }
+
+                               } catch ( Exception $e ) {
+                                       $prepend .= ResourceLoader::formatException( $e );
+                               }
+
+                               $css .= "\n";
                        }
-
-                       $css .= "\n";
+                       wfRestoreWarnings();
                }
-               wfRestoreWarnings();
+
+               $css = $prepend . $css;
 
                if ( $dir == 'rtl' ) {
                        $css = CSSJanus::transform( $css, true );
index 4600402..b629776 100644 (file)
@@ -251,9 +251,8 @@ class LinkHolderArray {
        }
 
        /**
-        * @todo FIXME: Update documentation. makeLinkObj() is deprecated.
         * Replace <!--LINK--> link placeholders with actual links, in the buffer
-        * Placeholders created in Skin::makeLinkObj()
+        *
         * @return array of link CSS classes, indexed by PDBK.
         */
        function replace( &$text ) {
index ef999d9..6380efc 100644 (file)
@@ -1223,6 +1223,13 @@ class ResourceLoader {
        public static function getLessCompiler() {
                global $wgResourceLoaderLESSFunctions, $wgResourceLoaderLESSImportPaths;
 
+               // When called from the installer, it is possible that a required PHP extension
+               // is missing (at least for now; see bug 47564). If this is the case, throw an
+               // exception (caught by the installer) to prevent a fatal error later on.
+               if ( !function_exists( 'ctype_digit' ) ) {
+                       throw new MWException( 'lessc requires the Ctype extension' );
+               }
+
                $less = new lessc();
                $less->setPreserveComments( true );
                $less->setVariables( self::getLESSVars() );
index 20f6e0b..b38f448 100644 (file)
@@ -41,7 +41,8 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule {
                        $wgVariantArticlePath, $wgActionPaths, $wgVersion,
                        $wgEnableAPI, $wgEnableWriteAPI, $wgDBname,
                        $wgSitename, $wgFileExtensions, $wgExtensionAssetsPath,
-                       $wgCookiePrefix, $wgResourceLoaderMaxQueryLength;
+                       $wgCookiePrefix, $wgResourceLoaderMaxQueryLength,
+                       $wgResourceLoaderStorageEnabled, $wgResourceLoaderStorageVersion;
 
                $mainPage = Title::newMainPage();
 
@@ -96,6 +97,8 @@ class ResourceLoaderStartUpModule extends ResourceLoaderModule {
                        'wgResourceLoaderMaxQueryLength' => $wgResourceLoaderMaxQueryLength,
                        'wgCaseSensitiveNamespaces' => $caseSensitiveNamespaces,
                        'wgLegalTitleChars' => Title::convertByteClassToUnicodeClass( Title::legalChars() ),
+                       'wgResourceLoaderStorageVersion' => $wgResourceLoaderStorageVersion,
+                       'wgResourceLoaderStorageEnabled' => $wgResourceLoaderStorageEnabled,
                );
 
                wfRunHooks( 'ResourceLoaderGetConfigVars', array( &$vars ) );
index f96902d..c595980 100644 (file)
@@ -9,6 +9,7 @@
                                        "mw.Map",
                                        "mw.Message",
                                        "mw.loader",
+                                       "mw.loader.store",
                                        "mw.log",
                                        "mw.html",
                                        "mw.html.Cdata",
index 4acc579..042790f 100644 (file)
@@ -53,22 +53,23 @@ class PopulateRevisionLength extends LoggedUpdateMaintenance {
                }
 
                $this->output( "Populating rev_len column\n" );
-               $rev = $this->doLenUpdates( 'revision', 'rev_id', 'rev' );
+               $rev = $this->doLenUpdates( 'revision', 'rev_id', 'rev', Revision::selectFields() );
 
                $this->output( "Populating ar_len column\n" );
-               $ar = $this->doLenUpdates( 'archive', 'ar_id', 'ar' );
+               $ar = $this->doLenUpdates( 'archive', 'ar_id', 'ar', Revision::selectArchiveFields() );
 
                $this->output( "rev_len and ar_len population complete [$rev revision rows, $ar archive rows].\n" );
                return true;
        }
 
        /**
-        * @param $table
-        * @param $idCol
-        * @param $prefix
+        * @param string $table
+        * @param string $idCol
+        * @param string $prefix
+        * @param array $fields
         * @return int
         */
-       protected function doLenUpdates( $table, $idCol, $prefix ) {
+       protected function doLenUpdates( $table, $idCol, $prefix, $fields ) {
                $db = $this->getDB( DB_MASTER );
                $start = $db->selectField( $table, "MIN($idCol)", false, __METHOD__ );
                $end = $db->selectField( $table, "MAX($idCol)", false, __METHOD__ );
@@ -81,9 +82,9 @@ class PopulateRevisionLength extends LoggedUpdateMaintenance {
                $blockStart = intval( $start );
                $blockEnd = intval( $start ) + $this->mBatchSize - 1;
                $count = 0;
-               $fields = Revision::selectFields();
+
                while ( $blockStart <= $end ) {
-                       $this->output( "...doing rev_id from $blockStart to $blockEnd\n" );
+                       $this->output( "...doing $idCol from $blockStart to $blockEnd\n" );
                        $res = $db->select(
                                $table,
                                $fields,
@@ -114,9 +115,9 @@ class PopulateRevisionLength extends LoggedUpdateMaintenance {
 
        /**
         * @param $row
-        * @param $table
-        * @param $idCol
-        * @param $prefix
+        * @param string $table
+        * @param string $idCol
+        * @param string $prefix
         * @return bool
         */
        protected function upgradeRow( $row, $table, $idCol, $prefix ) {
index 2c02de8..ec6c71b 100644 (file)
@@ -87,7 +87,7 @@ return array(
                'localBasePath' => $GLOBALS['wgStyleDirectory'],
        ),
        'skins.vector' => array(
-               // Keep in sync with WebInstallerOutput::getCSS()
+               // Used in the web installer. Test it after modifying this definition!
                'styles' => array(
                        'common/commonElements.css' => array( 'media' => 'screen' ),
                        'common/commonContent.css' => array( 'media' => 'screen' ),
@@ -1109,8 +1109,9 @@ return array(
                'localBasePath' => $GLOBALS['wgStyleDirectory'],
        ),
        'mediawiki.legacy.config' => array(
+               // Used in the web installer. Test it after modifying this definition!
                'scripts' => 'common/config.js',
-               'styles' => array( 'common/config.css', 'common/config-cc.css' ),
+               'styles' => array( 'common/config.css' ),
                'remoteBasePath' => $GLOBALS['wgStylePath'],
                'localBasePath' => $GLOBALS['wgStyleDirectory'],
                'dependencies' => 'mediawiki.legacy.wikibits',
@@ -1132,6 +1133,7 @@ return array(
                'position' => 'top',
        ),
        'mediawiki.legacy.shared' => array(
+               // Used in the web installer. Test it after modifying this definition!
                'styles' => array( 'common/shared.css' => array( 'media' => 'screen' ) ),
                'remoteBasePath' => $GLOBALS['wgStylePath'],
                'localBasePath' => $GLOBALS['wgStyleDirectory'],
index 03248ba..acda5d6 100644 (file)
@@ -848,8 +848,9 @@ var mw = ( function ( $, undefined ) {
                                }
 
                                if ( registry[module].state === 'ready' ) {
-                                       // The current module became 'ready'. Recursively execute all dependent modules that are loaded
-                                       // and now have all dependencies satisfied.
+                                       // The current module became 'ready'. Set it in the module store, and recursively execute all
+                                       // dependent modules that are loaded and now have all dependencies satisfied.
+                                       mw.loader.store.set( module, registry[module] );
                                        for ( m in registry ) {
                                                if ( registry[m].state === 'loaded' && allReady( registry[m].dependencies ) ) {
                                                        execute( m );
@@ -1203,7 +1204,7 @@ var mw = ( function ( $, undefined ) {
                                addScript( sourceLoadScript + '?' + $.param( request ) + '&*', null, async );
                        }
 
-                       /* Public Methods */
+                       /* Public Members */
                        return {
                                /**
                                 * The module registry is exposed as an aid for debugging and inspecting page
@@ -1252,6 +1253,19 @@ var mw = ( function ( $, undefined ) {
                                                        }
                                                }
                                        }
+
+                                       mw.loader.store.init();
+                                       if ( mw.loader.store.enabled ) {
+                                               batch = $.grep( batch, function ( module ) {
+                                                       var source = mw.loader.store.get( module );
+                                                       if ( source ) {
+                                                               $.globalEval( source );
+                                                               return false; // Don't fetch
+                                                       }
+                                                       return true; // Fetch
+                                               } );
+                                       }
+
                                        // Early exit if there's nothing to load...
                                        if ( !batch.length ) {
                                                return;
@@ -1703,8 +1717,237 @@ var mw = ( function ( $, undefined ) {
                                        mw.loader.using( 'mediawiki.inspect', function () {
                                                mw.inspect.runReports.apply( mw.inspect, args );
                                        } );
-                               }
+                               },
+
+                               /**
+                                * On browsers that implement the localStorage API, the module store serves as a
+                                * smart complement to the browser cache. Unlike the browser cache, the module store
+                                * can slice a concatenated response from ResourceLoader into its constituent
+                                * modules and cache each of them separately, using each module's versioning scheme
+                                * to determine when the cache should be invalidated.
+                                *
+                                * @singleton
+                                * @class mw.loader.store
+                                */
+                               store: {
+                                       // Whether the store is in use on this page.
+                                       enabled: null,
 
+                                       // The contents of the store, mapping '[module name]@[version]' keys
+                                       // to module implementations.
+                                       items: {},
+
+                                       // Cache hit stats
+                                       stats: { hits: 0, misses: 0, expired: 0 },
+
+                                       /**
+                                        * Construct a JSON-serializable object representing the content of the store.
+                                        * @return {Object} Module store contents.
+                                        */
+                                       toJSON: function () {
+                                               return { items: mw.loader.store.items, vary: mw.loader.store.getVary() };
+                                       },
+
+                                       /**
+                                        * Get the localStorage key for the entire module store. The key references
+                                        * $wgDBname to prevent clashes between wikis which share a common host.
+                                        *
+                                        * @return {string} localStorage item key
+                                        */
+                                       getStoreKey: function () {
+                                               return 'MediaWikiModuleStore:' + mw.config.get( 'wgDBname' );
+                                       },
+
+                                       /**
+                                        * Get a string key on which to vary the module cache.
+                                        * @return {string} String of concatenated vary conditions.
+                                        */
+                                       getVary: function () {
+                                               return [
+                                                       mw.config.get( 'skin' ),
+                                                       mw.config.get( 'wgResourceLoaderStorageVersion' ),
+                                                       mw.config.get( 'wgUserLanguage' )
+                                               ].join(':');
+                                       },
+
+                                       /**
+                                        * Get a string key for a specific module. The key format is '[name]@[version]'.
+                                        *
+                                        * @param {string} module Module name
+                                        * @return {string|null} Module key or null if module does not exist
+                                        */
+                                       getModuleKey: function ( module ) {
+                                               return typeof registry[module] === 'object' ?
+                                                       ( module + '@' + registry[module].version ) : null;
+                                       },
+
+                                       /**
+                                        * Initialize the store by retrieving it from localStorage and (if successfully
+                                        * retrieved) decoding the stored JSON value to a plain object.
+                                        *
+                                        * The try / catch block is used for JSON & localStorage feature detection.
+                                        * See the in-line documentation for Modernizr's localStorage feature detection
+                                        * code for a full account of why we need a try / catch: <http://git.io/4NEwKg>.
+                                        */
+                                       init: function () {
+                                               var raw, data;
+
+                                               if ( mw.loader.store.enabled !== null ) {
+                                                       // #init already ran.
+                                                       return;
+                                               }
+
+                                               if ( !mw.config.get( 'wgResourceLoaderStorageEnabled' ) || mw.config.get( 'debug' ) ) {
+                                                       // Disabled by configuration, or because debug mode is set.
+                                                       mw.loader.store.enabled = false;
+                                                       return;
+                                               }
+
+                                               try {
+                                                       raw = localStorage.getItem( mw.loader.store.getStoreKey() );
+                                                       // If we get here, localStorage is available; mark enabled.
+                                                       mw.loader.store.enabled = true;
+                                                       data = JSON.parse( raw );
+                                                       if ( data && typeof data.items === 'object' && data.vary === mw.loader.store.getVary() ) {
+                                                               mw.loader.store.items = data.items;
+                                                               return;
+                                                       }
+                                               } catch (e) {}
+
+                                               if ( raw === undefined ) {
+                                                       mw.loader.store.enabled = false;  // localStorage failed; disable store.
+                                               } else {
+                                                       mw.loader.store.update();
+                                               }
+                                       },
+
+                                       /**
+                                        * Retrieve a module from the store and update cache hit stats.
+                                        *
+                                        * @param {string} module Module name
+                                        * @return {string|boolean} Module implementation or false if unavailable
+                                        */
+                                       get: function ( module ) {
+                                               var key;
+
+                                               if ( mw.loader.store.enabled !== true ) {
+                                                       return false;
+                                               }
+
+                                               key = mw.loader.store.getModuleKey( module );
+                                               if ( key in mw.loader.store.items ) {
+                                                       mw.loader.store.stats.hits++;
+                                                       return mw.loader.store.items[key];
+                                               }
+                                               mw.loader.store.stats.misses++;
+                                               return false;
+                                       },
+
+                                       /**
+                                        * Stringify a module and queue it for storage.
+                                        *
+                                        * @param {string} module Module name
+                                        * @param {Object} descriptor The module's descriptor as set in the registry
+                                        */
+                                       set: function ( module, descriptor ) {
+                                               var args, key;
+
+                                               if ( mw.loader.store.enabled !== true ) {
+                                                       return false;
+                                               }
+
+                                               key = mw.loader.store.getModuleKey( module );
+
+                                               if ( key in mw.loader.store.items ) {
+                                                       // Already set; decline to store.
+                                                       return false;
+                                               }
+
+                                               if ( descriptor.state !== 'ready' ) {
+                                                       // Module failed to load; decline to store.
+                                                       return false;
+                                               }
+
+                                               if ( !descriptor.version || $.inArray( descriptor.group, [ 'private', 'user', 'site' ] ) !== -1 ) {
+                                                       // Unversioned, private, or site-/user-specific; decline to store.
+                                                       return false;
+                                               }
+
+                                               if ( $.inArray( undefined, [ descriptor.script, descriptor.style, descriptor.messages ] ) !== -1 ) {
+                                                       // Partial descriptor; decline to store.
+                                                       return false;
+                                               }
+
+                                               try {
+                                                       args = [
+                                                               JSON.stringify( module ),
+                                                               typeof descriptor.script === 'function' ?
+                                                                       String( descriptor.script ) : JSON.stringify( descriptor.script ),
+                                                               JSON.stringify( descriptor.style ),
+                                                               JSON.stringify( descriptor.messages )
+                                                       ];
+                                               } catch (e) {
+                                                       return;
+                                               }
+                                               mw.loader.store.items[key] = 'mw.loader.implement(' + args.join(',') + ');';
+                                               mw.loader.store.update();
+                                       },
+
+                                       /**
+                                        * Iterate through the module store, removing any item that does not correspond
+                                        * (in name and version) to an item in the module registry.
+                                        */
+                                       prune: function () {
+                                               var key, module;
+
+                                               if ( mw.loader.store.enabled !== true ) {
+                                                       return false;
+                                               }
+
+                                               for ( key in mw.loader.store.items ) {
+                                                       module = key.substring( 0, key.indexOf( '@' ) );
+                                                       if ( mw.loader.store.getModuleKey( module ) !== key ) {
+                                                               mw.loader.store.stats.expired++;
+                                                               delete mw.loader.store.items[key];
+                                                       }
+                                               }
+                                       },
+
+                                       /**
+                                        * Sync modules to localStorage.
+                                        *
+                                        * This function debounces localStorage updates. When called multiple times in
+                                        * quick succession, the calls are coalesced into a single update operation.
+                                        * This allows us to call #update without having to consider the module load
+                                        * queue; the call to localStorage.setItem will be naturally deferred until the
+                                        * page is quiescent.
+                                        *
+                                        * Because localStorage is shared by all pages with the same origin, if multiple
+                                        * pages are loaded with different module sets, the possibility exists that
+                                        * modules saved by one page will be clobbered by another. But the impact would
+                                        * be minor and the problem would be corrected by subsequent page views.
+                                        */
+                                       update: ( function () {
+                                               var timer;
+
+                                               function flush() {
+                                                       var data;
+                                                       if ( mw.loader.store.enabled !== true ) {
+                                                               return false;
+                                                       }
+                                                       mw.loader.store.prune();
+                                                       try {
+                                                               data = JSON.stringify( mw.loader.store );
+                                                               localStorage.setItem( mw.loader.store.getStoreKey(), data );
+                                                       } catch (e) {}
+                                               }
+
+                                               return function () {
+                                                       clearTimeout( timer );
+                                                       timer = setTimeout( flush, 2000 );
+                                               };
+                                       }() )
+                               }
                        };
                }() ),
 
diff --git a/tests/phpunit/data/autoloader/TestAutoloadedCamlClass.php b/tests/phpunit/data/autoloader/TestAutoloadedCamlClass.php
new file mode 100644 (file)
index 0000000..6dfce7a
--- /dev/null
@@ -0,0 +1,4 @@
+<?php
+
+class TestAutoloadedCamlClass {
+}
diff --git a/tests/phpunit/data/autoloader/TestAutoloadedClass.php b/tests/phpunit/data/autoloader/TestAutoloadedClass.php
new file mode 100644 (file)
index 0000000..9ceedf6
--- /dev/null
@@ -0,0 +1,4 @@
+<?php
+
+class TestAutoloadedClass {
+}
diff --git a/tests/phpunit/data/autoloader/TestAutoloadedLocalClass.php b/tests/phpunit/data/autoloader/TestAutoloadedLocalClass.php
new file mode 100644 (file)
index 0000000..1b397cd
--- /dev/null
@@ -0,0 +1,4 @@
+<?php
+
+class TestAutoloadedLocalClass {
+}
diff --git a/tests/phpunit/data/autoloader/TestAutoloadedSerializedClass.php b/tests/phpunit/data/autoloader/TestAutoloadedSerializedClass.php
new file mode 100644 (file)
index 0000000..80b9d58
--- /dev/null
@@ -0,0 +1,4 @@
+<?php
+
+class TestAutoloadedSerializedClass {
+}
index 205ea36..f5ff1d9 100644 (file)
@@ -1,6 +1,26 @@
 <?php
 class AutoLoaderTest extends MediaWikiTestCase {
 
+       protected function setUp() {
+               global $wgAutoloadLocalClasses, $wgAutoloadClasses;
+
+               parent::setUp();
+
+               // Fancy dance to trigger a rebuild of AutoLoader::$autoloadLocalClassesLower
+               $this->testLocalClasses = array(
+                       'TestAutoloadedLocalClass' => __DIR__ . '/../data/autoloader/TestAutoloadedLocalClass.php',
+                       'TestAutoloadedCamlClass' => __DIR__ . '/../data/autoloader/TestAutoloadedCamlClass.php',
+                       'TestAutoloadedSerializedClass' => __DIR__ . '/../data/autoloader/TestAutoloadedSerializedClass.php',
+               );
+               $this->setMwGlobals( 'wgAutoloadLocalClasses', $this->testLocalClasses + $wgAutoloadLocalClasses );
+               InstrumentedAutoLoader::resetAutoloadLocalClassesLower();
+
+               $this->testExtensionClasses = array(
+                       'TestAutoloadedClass' => __DIR__ . '/../data/autoloader/TestAutoloadedClass.php',
+               );
+               $this->setMwGlobals( 'wgAutoloadClasses', $this->testExtensionClasses + $wgAutoloadClasses );
+       }
+
        /**
         * Assert that there were no classes loaded that are not registered with the AutoLoader.
         *
@@ -53,4 +73,32 @@ class AutoLoaderTest extends MediaWikiTestCase {
                        'actual' => $actual,
                );
        }
+
+       function testCoreClass() {
+               $this->assertTrue( class_exists( 'TestAutoloadedLocalClass' ) );
+       }
+
+       function testExtensionClass() {
+               $this->assertTrue( class_exists( 'TestAutoloadedClass' ) );
+       }
+
+       function testWrongCaseClass() {
+               $this->assertTrue( class_exists( 'testautoLoadedcamlCLASS' ) );
+       }
+
+       function testWrongCaseSerializedClass() {
+               $dummyCereal = 'O:29:"testautoloadedserializedclass":0:{}';
+               $uncerealized = unserialize( $dummyCereal );
+               $this->assertFalse( $uncerealized instanceof __PHP_Incomplete_Class,
+                       "unserialize() can load classes case-insensitively.");
+       }
+}
+
+/**
+ * Cheater to poke protected members
+ */
+class InstrumentedAutoLoader extends AutoLoader {
+       static function resetAutoloadLocalClassesLower() {
+               self::$autoloadLocalClassesLower = null;
+       }
 }